import {
	getSubdomainMixedStateCheckDisabled,
	UserError,
} from "@cloudflare/workers-utils";
import chalk from "chalk";
import PQueue from "p-queue";
import { fetchListResult, fetchResult } from "../cfetch";
import {
	formatTime,
	publishCustomDomains,
	publishRoutes,
	renderRoute,
	updateQueueConsumers,
	validateRoutes,
} from "../deploy/deploy";
import { isNonInteractiveOrCI } from "../is-interactive";
import { logger } from "../logger";
import { ensureQueuesExistByConfig } from "../queues/client";
import { getWorkersDevSubdomain } from "../routes";
import { retryOnAPIFailure } from "../utils/retry";
import { getZoneForRoute } from "../zones";
import type { AssetsOptions } from "../assets";
import type { RouteObject } from "../deploy/deploy";
import type { Config, Route } from "@cloudflare/workers-utils";

type Props = {
	config: Config;
	accountId: string | undefined;
	name: string | undefined;
	env: string | undefined;
	triggers: string[] | undefined;
	routes: Route[] | undefined;
	useServiceEnvironments: boolean | undefined;
	dryRun: boolean | undefined;
	assetsOptions: AssetsOptions | undefined;
	firstDeploy: boolean;
};

export default async function triggersDeploy(
	props: Props
): Promise<string[] | void> {
	const { config, accountId, name: scriptName } = props;

	const schedules = props.triggers || config.triggers?.crons;
	const routes =
		props.routes ?? config.routes ?? (config.route ? [config.route] : []) ?? [];
	const routesOnly: Array<Route> = [];
	const customDomainsOnly: Array<RouteObject> = [];
	validateRoutes(routes, props.assetsOptions);
	for (const route of routes) {
		if (typeof route !== "string" && route.custom_domain) {
			customDomainsOnly.push(route);
		} else {
			routesOnly.push(route);
		}
	}

	if (!scriptName) {
		throw new UserError(
			'You need to provide a name when uploading a Worker Version. Either pass it as a cli arg with `--name <name>` or in your config file as `name = "<name>"`',
			{ telemetryMessage: true }
		);
	}

	const envName = props.env ?? "production";

	const start = Date.now();
	const useServiceEnvironments = Boolean(
		props.useServiceEnvironments && props.env
	);
	const workerName = useServiceEnvironments
		? `${scriptName} (${envName})`
		: scriptName;
	const workerUrl = useServiceEnvironments
		? `/accounts/${accountId}/workers/services/${scriptName}/environments/${envName}`
		: `/accounts/${accountId}/workers/scripts/${scriptName}`;

	if (!props.dryRun) {
		await ensureQueuesExistByConfig(config);
	}

	if (props.dryRun) {
		logger.log(`--dry-run: exiting now.`);
		return;
	}

	if (!accountId) {
		throw new UserError("Missing accountId", { telemetryMessage: true });
	}

	const uploadMs = Date.now() - start;
	const deployments: Promise<string[]>[] = [];

	const { wantWorkersDev, workersDevInSync } = await subdomainDeploy(
		props,
		accountId,
		scriptName,
		envName,
		workerUrl,
		routes,
		deployments,
		props.firstDeploy
	);

	if (!wantWorkersDev && workersDevInSync && routes.length !== 0) {
		// TODO is this true? How does last subdomain status affect route confict??
		// Why would we only need to validate route conflicts if didn't need to
		// disable the subdomain deployment?

		// if you get to this point it's because
		// you're trying to deploy a worker to a route
		// that's already bound to another worker.
		// so this thing is about finding workers that have
		// bindings to the routes you're trying to deploy to.
		//
		// the logic is kinda similar (read: duplicated) from publishRoutesFallback,
		// except here we know we have a good API token or whatever so we don't need
		// to bother with all the error handling tomfoolery.
		const routesWithOtherBindings: Record<string, string[]> = {};

		/**
		 * This queue ensures we limit how many concurrent fetch
		 * requests we're making to the Zones API.
		 */
		const queue = new PQueue({ concurrency: 10 });
		const queuePromises: Array<Promise<void>> = [];
		const zoneRoutesCache = new Map<
			string,
			Promise<Array<{ pattern: string; script: string }>>
		>();

		const zoneIdCache = new Map();
		for (const route of routes) {
			queuePromises.push(
				queue.add(async () => {
					const zone = await getZoneForRoute(
						config,
						{ route, accountId },
						zoneIdCache
					);
					if (!zone) {
						return;
					}

					const routePattern =
						typeof route === "string" ? route : route.pattern;

					let routesInZone = zoneRoutesCache.get(zone.id);
					if (!routesInZone) {
						routesInZone = retryOnAPIFailure(() =>
							fetchListResult<{
								pattern: string;
								script: string;
							}>(config, `/zones/${zone.id}/workers/routes`)
						);
						zoneRoutesCache.set(zone.id, routesInZone);
					}

					(await routesInZone).forEach(({ script, pattern }) => {
						if (pattern === routePattern && script !== scriptName) {
							if (!(script in routesWithOtherBindings)) {
								routesWithOtherBindings[script] = [];
							}

							routesWithOtherBindings[script].push(pattern);
						}
					});
				})
			);
		}
		// using Promise.all() here instead of queue.onIdle() to ensure
		// we actually throw errors that occur within queued promises.
		await Promise.all(queuePromises);

		if (Object.keys(routesWithOtherBindings).length > 0) {
			let errorMessage =
				"Can't deploy routes that are assigned to another worker.\n";

			for (const worker in routesWithOtherBindings) {
				const assignedRoutes = routesWithOtherBindings[worker];
				errorMessage += `"${worker}" is already assigned to routes:\n${assignedRoutes.map(
					(r) => `  - ${chalk.underline(r)}\n`
				)}`;
			}

			const resolution =
				"Unassign other workers from the routes you want to deploy to, and then try again.";
			const dashHref = chalk.blue.underline(
				`https://dash.cloudflare.com/${accountId}/workers/overview`
			);
			const dashLink = `Visit ${dashHref} to unassign a worker from a route.`;

			throw new UserError(`${errorMessage}\n${resolution}\n${dashLink}`);
		}
	}

	// Update routing table for the script.
	if (routesOnly.length > 0) {
		deployments.push(
			publishRoutes(config, routesOnly, {
				workerUrl,
				scriptName,
				useServiceEnvironments,
				accountId,
			}).then(() => {
				if (routesOnly.length > 10) {
					return routesOnly
						.slice(0, 9)
						.map((route) => renderRoute(route))
						.concat([`...and ${routesOnly.length - 10} more routes`]);
				}
				return routesOnly.map((route) => renderRoute(route));
			})
		);
	}

	// Update custom domains for the script
	if (customDomainsOnly.length > 0) {
		deployments.push(
			publishCustomDomains(config, workerUrl, accountId, customDomainsOnly)
		);
	}

	// Configure any schedules for the script.
	// If schedules is not defined then we just leave whatever is previously deployed alone.
	// If it is an empty array we will remove all schedules.
	if (schedules) {
		deployments.push(
			fetchResult(config, `${workerUrl}/schedules`, {
				// Note: PUT will override previous schedules on this script.
				method: "PUT",
				body: JSON.stringify(schedules.map((cron) => ({ cron }))),
				headers: {
					"Content-Type": "application/json",
				},
			}).then(() => schedules.map((trigger) => `schedule: ${trigger}`))
		);
	}

	if (config.queues.producers && config.queues.producers.length) {
		deployments.push(
			...config.queues.producers.map((producer) =>
				Promise.resolve([`Producer for ${producer.queue}`])
			)
		);
	}

	if (config.queues.consumers && config.queues.consumers.length) {
		const updateConsumers = await updateQueueConsumers(scriptName, config);

		deployments.push(...updateConsumers);
	}

	if (config.workflows?.length) {
		for (const workflow of config.workflows) {
			// NOTE: if the user provides a script_name thats not this script (aka bounds to another worker)
			// we don't want to send this worker's config.
			if (
				workflow.script_name !== undefined &&
				workflow.script_name !== scriptName
			) {
				continue;
			}

			deployments.push(
				fetchResult(
					config,
					`/accounts/${accountId}/workflows/${workflow.name}`,
					{
						method: "PUT",
						body: JSON.stringify({
							script_name: scriptName,
							class_name: workflow.class_name,
						}),
						headers: {
							"Content-Type": "application/json",
						},
					}
				).then(() => [`workflow: ${workflow.name}`])
			);
		}
	}

	const targets = await Promise.all(deployments);
	const deployMs = Date.now() - start - uploadMs;

	if (deployments.length > 0) {
		logger.log(`Deployed ${workerName} triggers`, formatTime(deployMs));

		const flatTargets = targets.flat().map(
			// Append protocol only on workers.dev domains
			(target) => (target.endsWith("workers.dev") ? "https://" : "") + target
		);

		for (const target of flatTargets) {
			logger.log(" ", target);
		}
		return flatTargets;
	} else {
		logger.log("No deploy targets for", workerName, formatTime(deployMs));
	}
}

// getSubdomainValues returns the values for workers_dev and preview_urls.
// Defaults are computed at the API level.
export function getSubdomainValues(
	config_workers_dev: boolean | undefined,
	config_preview_urls: boolean | undefined,
	routes: Route[]
): {
	workers_dev: boolean;
	preview_urls?: boolean;
} {
	const defaultWorkersDev = routes.length === 0; // Default to true only if there aren't any routes defined.
	const workers_dev = config_workers_dev ?? defaultWorkersDev;
	const defaultPreviewUrls = undefined; // Undefined lets the API compute the default.
	const preview_urls = config_preview_urls ?? defaultPreviewUrls;
	return {
		workers_dev,
		preview_urls,
	};
}

// getSubdomainValuesAPIMock returns the values for workers_dev and preview_urls.
// Mimics the logic in the API, ideally we would obtain defaults from the API.
export function getSubdomainValuesAPIMock(
	config_workers_dev: boolean | undefined,
	config_preview_urls: boolean | undefined,
	routes: Route[]
): {
	workers_dev: boolean;
	preview_urls: boolean;
} {
	const defaultWorkersDev = routes.length === 0; // Default to true only if there aren't any routes defined.
	const workers_dev = config_workers_dev ?? defaultWorkersDev;
	const defaultPreviewUrls = defaultWorkersDev; // Default to workers_dev status.
	const preview_urls = config_preview_urls ?? defaultPreviewUrls;
	return {
		workers_dev,
		preview_urls,
	};
}

async function validateSubdomainMixedState(
	props: Props,
	accountId: string,
	scriptName: string,
	before: { workers_dev: boolean; preview_urls: boolean },
	after: { workers_dev: boolean; preview_urls: boolean },
	firstDeploy: boolean
): Promise<{
	workers_dev: boolean;
	preview_urls: boolean;
}> {
	const { config } = props;

	const changed =
		after.workers_dev !== before.workers_dev ||
		after.preview_urls !== before.preview_urls;

	// Early return if config values are the same as remote values (so we only warn on change)
	if (!changed) {
		return after;
	}

	// Early return if check disabled through environment variable.
	if (getSubdomainMixedStateCheckDisabled()) {
		return after;
	}

	// Early return if non-interactive or CI
	if (isNonInteractiveOrCI()) {
		return after;
	}

	// Early return if this is the first deploy
	if (firstDeploy) {
		return after;
	}

	// Early return if config values are the same (e.g. both true or both false, not in mixed state)
	if (after.workers_dev === after.preview_urls) {
		return after;
	}

	const userSubdomain = await getWorkersDevSubdomain(
		config,
		accountId,
		config.configPath
	);
	const previewUrl = `https://<VERSION_PREFIX>-${scriptName}.${userSubdomain}`;

	// Scenario 1: User disables workers.dev while having preview URLs enabled
	if (!after.workers_dev && after.preview_urls) {
		logger.warn(
			[
				"You are disabling the 'workers.dev' subdomain for this Worker, but Preview URLs are still enabled.",
				"Preview URLs will automatically generate a unique, shareable link for each new version which will be accessible at:",
				`  ${previewUrl}`,
				"",
				"To prevent this Worker from being unintentionally public, you may want to disable the Preview URLs as well by setting `preview_urls = false` in your Wrangler config file.",
			].join("\n")
		);
	}

	// Scenario 2: User enables workers.dev when Preview URLs are off
	if (after.workers_dev && !after.preview_urls) {
		logger.warn(
			[
				"You are enabling the 'workers.dev' subdomain for this Worker, but Preview URLs are still disabled.",
				"Preview URLs will automatically generate a unique, shareable link for each new version which will be accessible at:",
				`  ${previewUrl}`,
				"",
				"You may want to enable the Preview URLs as well by setting `preview_urls = true` in your Wrangler config file.",
			].join("\n")
		);
	}

	return after;
}

async function subdomainDeploy(
	props: Props,
	accountId: string,
	scriptName: string,
	envName: string,
	workerUrl: string,
	routes: Route[],
	deployments: Array<Promise<string[]>>,
	firstDeploy: boolean
) {
	const { config } = props;

	// Get desired subdomain enablement status.

	const { workers_dev: wantWorkersDev, preview_urls: wantPreviews } =
		getSubdomainValues(config.workers_dev, config.preview_urls, routes);

	// workers.dev URL is only set if we want to deploy to workers.dev.

	let workersDevURL: string | undefined;
	if (wantWorkersDev) {
		const userSubdomain = await getWorkersDevSubdomain(
			config,
			accountId,
			config.configPath
		);
		workersDevURL =
			!props.useServiceEnvironments || !props.env
				? `${scriptName}.${userSubdomain}`
				: `${envName}.${scriptName}.${userSubdomain}`;
	}

	// Get current subdomain enablement status.

	const before = await fetchResult<{
		enabled: boolean;
		previews_enabled: boolean;
	}>(config, `${workerUrl}/subdomain`);

	// Update subdomain status.
	// Occasionally this update to the subdomain endpoint fails due to some internal API error,
	// we retry this request a few times to mitigate that.

	const after = await retryOnAPIFailure(async () =>
		fetchResult<{
			enabled: boolean;
			previews_enabled: boolean;
		}>(config, `${workerUrl}/subdomain`, {
			method: "POST",
			body: JSON.stringify({
				enabled: wantWorkersDev,
				previews_enabled: wantPreviews,
			}),
			headers: {
				"Content-Type": "application/json",
				"Cloudflare-Workers-Script-Api-Date": "2025-08-01",
			},
		})
	);

	// Warn about mismatching config and current values.

	if (
		!firstDeploy &&
		config.workers_dev == undefined &&
		after.enabled !== before.enabled
	) {
		const status = (enabled: boolean, past: boolean) => {
			if (past) {
				return enabled ? "enabled" : "disabled";
			} else {
				return enabled ? "enable" : "disable";
			}
		};
		logger.warn(
			[
				`Because 'workers_dev' is not in your Wrangler file, it will be ${status(after.enabled, true)} for this deployment by default.`,
				`To override this setting, you can ${status(before.enabled, false)} workers.dev by explicitly setting 'workers_dev = ${before.enabled}' in your Wrangler file.`,
			].join("\n")
		);
	}

	if (
		!firstDeploy &&
		config.preview_urls == undefined &&
		after.previews_enabled !== before.previews_enabled
	) {
		const status = (enabled: boolean, past: boolean) => {
			if (past) {
				return enabled ? "enabled" : "disabled";
			} else {
				return enabled ? "enable" : "disable";
			}
		};
		logger.warn(
			[
				`Because your 'workers.dev' route is ${status(after.enabled, true)} and your 'preview_urls' setting is not in your Wrangler file, Preview URLs will be ${status(after.previews_enabled, true)} for this deployment by default.`,
				`To override this setting, you can ${status(before.previews_enabled, false)} Preview URLs by explicitly setting 'preview_urls = ${before.previews_enabled}' in your Wrangler file.`,
			].join("\n")
		);
	}

	// Warn about mixed status.

	await validateSubdomainMixedState(
		props,
		accountId,
		scriptName,
		{ workers_dev: before.enabled, preview_urls: before.previews_enabled },
		{ workers_dev: after.enabled, preview_urls: after.previews_enabled },
		firstDeploy
	);

	// Done.

	if (workersDevURL) {
		deployments.push(Promise.resolve([workersDevURL]));
	}
	return {
		wantWorkersDev,
		wantPreviews,
		workersDevInSync: before.enabled === after.enabled,
		previewsInSync: before.previews_enabled === after.previews_enabled,
	};
}
